page.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. 'use client';
  2. import { Stage } from '@/components/stage';
  3. import { ThemeProvider } from '@/lib/hooks/use-theme';
  4. import { useStageStore } from '@/lib/store';
  5. import { loadImageMapping } from '@/lib/utils/image-storage';
  6. import { useEffect, useRef, useState, useCallback } from 'react';
  7. import { useParams } from 'next/navigation';
  8. import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
  9. import { useMediaGenerationStore } from '@/lib/store/media-generation';
  10. import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
  11. import { createLogger } from '@/lib/logger';
  12. import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
  13. import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
  14. const log = createLogger('Classroom');
  15. export default function ClassroomDetailPage() {
  16. const params = useParams();
  17. const classroomId = params?.id as string;
  18. const { loadFromStorage } = useStageStore();
  19. const [loading, setLoading] = useState(true);
  20. const [error, setError] = useState<string | null>(null);
  21. const generationStartedRef = useRef(false);
  22. const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
  23. onComplete: () => {
  24. log.info('[Classroom] All scenes generated');
  25. },
  26. });
  27. const loadClassroom = useCallback(async () => {
  28. try {
  29. await loadFromStorage(classroomId);
  30. // If IndexedDB had no data, try server-side storage (API-generated classrooms)
  31. if (!useStageStore.getState().stage) {
  32. log.info('No IndexedDB data, trying server-side storage for:', classroomId);
  33. try {
  34. const res = await fetch(`/api/classroom?id=${encodeURIComponent(classroomId)}`);
  35. if (res.ok) {
  36. const json = await res.json();
  37. if (json.success && json.classroom) {
  38. const { stage, scenes } = json.classroom;
  39. useStageStore.getState().setStage(stage);
  40. useStageStore.setState({
  41. scenes,
  42. currentSceneId: scenes[0]?.id ?? null,
  43. });
  44. log.info('Loaded from server-side storage:', classroomId);
  45. // Hydrate server-generated agents into IndexedDB + registry.
  46. // Don't set selectedAgentIds here — the general agent
  47. // restoration logic below (Path 2) handles it uniformly.
  48. if (stage.generatedAgentConfigs?.length) {
  49. const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store');
  50. await saveGeneratedAgents(stage.id, stage.generatedAgentConfigs);
  51. log.info('Hydrated server-generated agents for stage:', stage.id);
  52. }
  53. }
  54. }
  55. } catch (fetchErr) {
  56. log.warn('Server-side storage fetch failed:', fetchErr);
  57. }
  58. }
  59. // Restore completed media generation tasks from IndexedDB
  60. await useMediaGenerationStore.getState().restoreFromDB(classroomId);
  61. // Restore agents for this stage
  62. const { loadGeneratedAgentsForStage, useAgentRegistry } =
  63. await import('@/lib/orchestration/registry/store');
  64. const generatedAgentIds = await loadGeneratedAgentsForStage(classroomId);
  65. const { useSettingsStore } = await import('@/lib/store/settings');
  66. if (generatedAgentIds.length > 0) {
  67. // Auto mode — use generated agents from IndexedDB
  68. useSettingsStore.getState().setAgentMode('auto');
  69. useSettingsStore.getState().setSelectedAgentIds(generatedAgentIds);
  70. } else {
  71. // Preset mode — restore agent IDs saved in the stage at creation time.
  72. // Filter out any stale generated IDs that may have been persisted before
  73. // the bleed-fix, so they don't resolve against a leftover registry entry.
  74. const stage = useStageStore.getState().stage;
  75. const stageAgentIds = stage?.agentIds;
  76. const registry = useAgentRegistry.getState();
  77. const cleanIds = stageAgentIds?.filter((id) => {
  78. const a = registry.getAgent(id);
  79. return a && !a.isGenerated;
  80. });
  81. useSettingsStore.getState().setAgentMode('preset');
  82. useSettingsStore
  83. .getState()
  84. .setSelectedAgentIds(
  85. cleanIds && cleanIds.length > 0 ? cleanIds : ['default-1', 'default-2', 'default-3'],
  86. );
  87. }
  88. } catch (error) {
  89. log.error('Failed to load classroom:', error);
  90. setError(error instanceof Error ? error.message : 'Failed to load classroom');
  91. } finally {
  92. setLoading(false);
  93. }
  94. }, [classroomId, loadFromStorage]);
  95. useEffect(() => {
  96. // Reset loading state on course switch to unmount Stage during transition,
  97. // preventing stale data from syncing back to the new course
  98. setLoading(true);
  99. setError(null);
  100. generationStartedRef.current = false;
  101. // Clear previous classroom's media tasks to prevent cross-classroom contamination.
  102. // Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages,
  103. // so stale tasks from a previous classroom would shadow the new one's.
  104. const mediaStore = useMediaGenerationStore.getState();
  105. mediaStore.revokeObjectUrls();
  106. useMediaGenerationStore.setState({ tasks: {} });
  107. // Clear whiteboard history to prevent snapshots from a previous course leaking in.
  108. useWhiteboardHistoryStore.getState().clearHistory();
  109. loadClassroom();
  110. // Cancel ongoing generation when classroomId changes or component unmounts
  111. return () => {
  112. stop();
  113. };
  114. }, [classroomId, loadClassroom, stop]);
  115. // Auto-resume generation for pending outlines
  116. useEffect(() => {
  117. if (loading || error || generationStartedRef.current) return;
  118. const state = useStageStore.getState();
  119. const { outlines, scenes, stage } = state;
  120. // Check if there are pending outlines
  121. const completedOrders = new Set(scenes.map((s) => s.order));
  122. const hasPending = outlines.some((o) => !completedOrders.has(o.order));
  123. if (hasPending && stage) {
  124. generationStartedRef.current = true;
  125. // Load generation params from sessionStorage (stored by generation-preview before navigating)
  126. const genParamsStr = sessionStorage.getItem('generationParams');
  127. const params = genParamsStr ? JSON.parse(genParamsStr) : {};
  128. // Reconstruct imageMapping from IndexedDB using pdfImages storageIds
  129. const storageIds = (params.pdfImages || [])
  130. .map((img: { storageId?: string }) => img.storageId)
  131. .filter(Boolean);
  132. loadImageMapping(storageIds).then((imageMapping) => {
  133. generateRemaining({
  134. pdfImages: params.pdfImages,
  135. imageMapping,
  136. stageInfo: {
  137. name: stage.name || '',
  138. description: stage.description,
  139. language: stage.language,
  140. style: stage.style,
  141. },
  142. agents: params.agents,
  143. userProfile: params.userProfile,
  144. });
  145. });
  146. } else if (outlines.length > 0 && stage) {
  147. // All scenes are generated, but some media may not have finished.
  148. // Resume media generation for any tasks not yet in IndexedDB.
  149. // generateMediaForOutlines skips already-completed tasks automatically.
  150. generationStartedRef.current = true;
  151. generateMediaForOutlines(outlines, stage.id).catch((err) => {
  152. log.warn('[Classroom] Media generation resume error:', err);
  153. });
  154. }
  155. }, [loading, error, generateRemaining]);
  156. return (
  157. <ThemeProvider>
  158. <MediaStageProvider value={classroomId}>
  159. <div className="h-screen flex flex-col overflow-hidden">
  160. {loading ? (
  161. <div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
  162. <div className="text-center text-muted-foreground">
  163. <p>Loading classroom...</p>
  164. </div>
  165. </div>
  166. ) : error ? (
  167. <div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
  168. <div className="text-center">
  169. <p className="text-destructive mb-4">Error: {error}</p>
  170. <button
  171. onClick={() => {
  172. setError(null);
  173. setLoading(true);
  174. loadClassroom();
  175. }}
  176. className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
  177. >
  178. Retry
  179. </button>
  180. </div>
  181. </div>
  182. ) : (
  183. <Stage onRetryOutline={retrySingleOutline} />
  184. )}
  185. </div>
  186. </MediaStageProvider>
  187. </ThemeProvider>
  188. );
  189. }